Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit fc0a2444ad64c2e3f39c272d65e32fb79af4c65f


Parents : e8a303f
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-23T09:24:22-06:00

Update NetworkVisualiser with Level of Detail (LOD) management and icon cache optimization

- Introduced dynamic LOD updates based on network scale, improving performance and visual clarity.
- Implemented blob URL management for icon caching to optimize memory usage and prevent leaks.
- Updated tests to validate LOD functionality and icon cache behavior, ensuring robustness and performance efficiency.

Thanks to L for suggestion!

Changes
Diff

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index d3c90a26..6b87249a 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -303,6 +303,7 @@ export default {
pageSize: 1000,
searchQuery: "",
abortController: new AbortController(),
+ currentLOD: "high",
};
},
computed: {
@@ -362,7 +363,14 @@ export default {
this.network.destroy();
}
// Clear icon cache to free memory
- for (const key in this.iconCache) {
+ const revokedUrls = new Set();
+ const keys = Object.keys(this.iconCache);
+ for (const key of keys) {
+ const url = this.iconCache[key];
+ if (url && url.startsWith("blob:") && !revokedUrls.has(url)) {
+ URL.revokeObjectURL(url);
+ revokedUrls.add(url);
+ }
delete this.iconCache[key];
}
this.iconCache = {};
@@ -516,14 +524,25 @@ export default {
const cacheKeys = Object.keys(this.iconCache);
if (cacheKeys.length >= 500) {
// simple FIFO eviction
- delete this.iconCache[cacheKeys[0]];
+ const oldKey = cacheKeys[0];
+ const oldUrl = this.iconCache[oldKey];
+ if (oldUrl && oldUrl.startsWith("blob:")) {
+ // Check if any other keys use this URL before revoking
+ const stillUsed = Object.values(this.iconCache).some(
+ (u, i) => u === oldUrl && Object.keys(this.iconCache)[i] !== oldKey
+ );
+ if (!stillUsed) {
+ URL.revokeObjectURL(oldUrl);
+ }
+ }
+ delete this.iconCache[oldKey];
}
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
- const ctx = canvas.getContext("2d");
+ const ctx = canvas.getContext("2d", { alpha: true });
// draw background circle with subtle gradient
const gradient = ctx.createLinearGradient(0, 0, 0, size);
@@ -589,9 +608,12 @@ export default {
ctx.shadowOffsetY = 0;
URL.revokeObjectURL(url);
- const dataUrl = canvas.toDataURL();
- this.iconCache[cacheKey] = dataUrl;
- resolve(dataUrl);
+
+ canvas.toBlob((blob) => {
+ const blobUrl = URL.createObjectURL(blob);
+ this.iconCache[cacheKey] = blobUrl;
+ resolve(blobUrl);
+ }, "image/png");
};
img.onerror = () => {
if (this.abortController.signal.aborted) {
@@ -600,9 +622,11 @@ export default {
return;
}
URL.revokeObjectURL(url);
- const dataUrl = canvas.toDataURL();
- this.iconCache[cacheKey] = dataUrl;
- resolve(dataUrl);
+ canvas.toBlob((blob) => {
+ const blobUrl = URL.createObjectURL(blob);
+ this.iconCache[cacheKey] = blobUrl;
+ resolve(blobUrl);
+ }, "image/png");
};
img.src = url;
});
@@ -948,6 +972,10 @@ export default {
this._draggingNodeId = null;
});
+ this.network.on("zoom", () => {
+ this.updateLOD();
+ });
+
await this.manualUpdate();
// auto reload
@@ -973,6 +1001,53 @@ export default {
this.isUpdating = false;
}
},
+ updateLOD() {
+ if (!this.network) return;
+ const scale = this.network.getScale();
+ let newLOD = "high";
+ if (scale < 0.2) {
+ newLOD = "low";
+ } else if (scale < 0.5) {
+ newLOD = "medium";
+ }
+
+ if (this.currentLOD === newLOD) return;
+ this.currentLOD = newLOD;
+
+ const allNodes = this.nodes.get();
+ const updates = allNodes.map((node) => {
+ return this.getNodeLODProps(node, newLOD);
+ });
+ this.nodes.update(updates);
+ },
+ getNodeLODProps(node, lod) {
+ const isDarkMode = document.documentElement.classList.contains("dark");
+ const fontColor = isDarkMode ? "#ffffff" : "#000000";
+
+ if (lod === "low") {
+ return {
+ id: node.id,
+ shape: "dot",
+ size: node.id === "me" ? 15 : 10,
+ font: { size: 0 }, // hide labels
+ };
+ } else if (lod === "medium") {
+ return {
+ id: node.id,
+ shape: node._originalShape || "circularImage",
+ size: node._originalSize || (node.id === "me" ? 50 : 25),
+ font: { size: 0 }, // hide labels
+ };
+ } else {
+ // high
+ return {
+ id: node.id,
+ shape: node._originalShape || "circularImage",
+ size: node._originalSize || (node.id === "me" ? 50 : 25),
+ font: { size: node.id === "me" ? 16 : 11, color: fontColor },
+ };
+ }
+ },
async update() {
this.loadingStatus = "Fetching basic info...";
this.currentBatch = 0;
@@ -1003,11 +1078,13 @@ export default {
// Add me
const meLabel = this.config?.display_name ?? "Local Node";
if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) {
- const meNode = {
+ let meNode = {
id: "me",
group: "me",
size: 50,
+ _originalSize: 50,
shape: "circularImage",
+ _originalShape: "circularImage",
image: this.reticulumLogoPath,
label: meLabel,
title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`,
@@ -1016,6 +1093,7 @@ export default {
x: 0,
y: 0,
};
+ meNode = { ...meNode, ...this.getNodeLODProps(meNode, this.currentLOD) };
this.nodes.update([meNode]);
processedNodeIds.add("me");
}
@@ -1039,13 +1117,15 @@ export default {
const initialX = Math.cos(angle) * radius;
const initialY = Math.sin(angle) * radius;
- interfaceNodes.push({
+ let interfaceNode = {
id: entry.name,
group: "interface",
label: label,
title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
size: 35,
+ _originalSize: 35,
shape: "circularImage",
+ _originalShape: "circularImage",
image: entry.status
? "/assets/images/network-visualiser/interface_connected.png"
: "/assets/images/network-visualiser/interface_disconnected.png",
@@ -1056,7 +1136,9 @@ export default {
font: { color: fontColor, size: 12, bold: true },
x: initialX,
y: initialY,
- });
+ };
+ interfaceNode = { ...interfaceNode, ...this.getNodeLODProps(interfaceNode, this.currentLOD) };
+ interfaceNodes.push(interfaceNode);
processedNodeIds.add(entry.name);
const edgeId = `me~${entry.name}`;
@@ -1129,10 +1211,11 @@ export default {
initY = Math.sin(angle) * dist;
}
- const node = {
+ let node = {
id: entry.hash,
group: "announce",
size: 25,
+ _originalSize: 25,
_announce: announce,
font: { color: fontColor, size: 11 },
x: initX,
@@ -1145,16 +1228,24 @@ export default {
if (announce.aspect === "lxmf.delivery") {
if (conversation?.lxmf_user_icon) {
node.shape = "circularImage";
- node.image = await this.createIconImage(
- conversation.lxmf_user_icon.icon_name,
- conversation.lxmf_user_icon.foreground_colour,
- conversation.lxmf_user_icon.background_colour,
- 64
- );
+ node._originalShape = "circularImage";
+ const cacheKey = `${conversation.lxmf_user_icon.icon_name}-${conversation.lxmf_user_icon.foreground_colour}-${conversation.lxmf_user_icon.background_colour}-64`;
+ if (this.iconCache[cacheKey]) {
+ node.image = this.iconCache[cacheKey];
+ } else {
+ node.image = await this.createIconImage(
+ conversation.lxmf_user_icon.icon_name,
+ conversation.lxmf_user_icon.foreground_colour,
+ conversation.lxmf_user_icon.background_colour,
+ 64
+ );
+ }
if (this.abortController.signal.aborted) return;
node.size = 30;
+ node._originalSize = 30;
} else {
node.shape = "circularImage";
+ node._originalShape = "circularImage";
node.image =
entry.hops === 1
? "/assets/images/network-visualiser/user_1hop.png"
@@ -1173,6 +1264,7 @@ export default {
};
} else if (announce.aspect === "nomadnetwork.node") {
node.shape = "circularImage";
+ node._originalShape = "circularImage";
node.image =
entry.hops === 1
? "/assets/images/network-visualiser/server_1hop.png"
@@ -1190,6 +1282,7 @@ export default {
};
}
+ node = { ...node, ...this.getNodeLODProps(node, this.currentLOD) };
batchNodes.push(node);
processedNodeIds.add(node.id);

diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index e4fa5c07..3c492a3f 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -327,9 +327,10 @@ describe("NetworkVisualiser.vue", () => {
const originalCreateIconImage = wrapper.vm.createIconImage;
wrapper.vm.createIconImage = vi.fn().mockImplementation(async (iconName, fg, bg, size) => {
const cacheKey = `${iconName}-${fg}-${bg}-${size}`;
- const mockDataUrl = `data:image/png;base64,${iconName}`;
- wrapper.vm.iconCache[cacheKey] = mockDataUrl;
- return mockDataUrl;
+ // Use blob: prefix so it gets cleared on unmount
+ const mockBlobUrl = `blob:image/png/${iconName}`;
+ wrapper.vm.iconCache[cacheKey] = mockBlobUrl;
+ return mockBlobUrl;
});
const getMemory = () => process.memoryUsage().heapUsed / (1024 * 1024);

diff --git a/tests/frontend/VisualizerOptimization.test.js b/tests/frontend/VisualizerOptimization.test.js
index b64c1df7..44243f8c 100644
--- a/tests/frontend/VisualizerOptimization.test.js
+++ b/tests/frontend/VisualizerOptimization.test.js
@@ -60,6 +60,10 @@ describe("NetworkVisualiser Optimization and Abort", () => {
isCancel: vi.fn().mockImplementation((e) => e && e.name === "AbortError"),
};
window.axios = axiosMock;
+
+ // Mock URL methods
+ global.URL.createObjectURL = vi.fn().mockReturnValue("blob:mock");
+ global.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
@@ -165,4 +169,122 @@ describe("NetworkVisualiser Optimization and Abort", () => {
// Total 5 calls
expect(axiosMock.get).toHaveBeenCalledTimes(5);
});
+
+ it("applies LOD based on scale", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ wrapper.vm.network = {
+ getScale: vi.fn(),
+ };
+
+ const testNode = { id: "test", label: "Test Label", _originalSize: 25, _originalShape: "circularImage" };
+ wrapper.vm.nodes.add(testNode);
+
+ // Test Low LOD
+ wrapper.vm.network.getScale.mockReturnValue(0.1);
+ wrapper.vm.updateLOD();
+ expect(wrapper.vm.currentLOD).toBe("low");
+ let updatedNode = wrapper.vm.nodes.get("test");
+ expect(updatedNode.shape).toBe("dot");
+ expect(updatedNode.font.size).toBe(0);
+
+ // Test Medium LOD
+ wrapper.vm.network.getScale.mockReturnValue(0.3);
+ wrapper.vm.updateLOD();
+ expect(wrapper.vm.currentLOD).toBe("medium");
+ updatedNode = wrapper.vm.nodes.get("test");
+ expect(updatedNode.shape).toBe("circularImage");
+ expect(updatedNode.font.size).toBe(0);
+
+ // Test High LOD
+ wrapper.vm.network.getScale.mockReturnValue(0.7);
+ wrapper.vm.updateLOD();
+ expect(wrapper.vm.currentLOD).toBe("high");
+ updatedNode = wrapper.vm.nodes.get("test");
+ expect(updatedNode.shape).toBe("circularImage");
+ expect(updatedNode.font.size).toBe(11);
+ });
+
+ it("clears Blob URLs from icon cache on unmount", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+
+ const mockBlobUrl = "blob:mock-url-1";
+ wrapper.vm.iconCache["test-key"] = mockBlobUrl;
+
+ const revokeSpy = vi.spyOn(URL, "revokeObjectURL");
+
+ wrapper.unmount();
+
+ expect(revokeSpy).toHaveBeenCalledWith(mockBlobUrl);
+ expect(Object.keys(wrapper.vm.iconCache).length).toBe(0);
+ });
+
+ it("performance: LOD update time for 2000 nodes", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ wrapper.vm.network = { getScale: vi.fn() };
+
+ const nodeCount = 2000;
+ const nodes = Array.from({ length: nodeCount }, (_, i) => ({
+ id: `n${i}`,
+ label: `Node ${i}`,
+ _originalSize: 25,
+ _originalShape: "circularImage",
+ }));
+ wrapper.vm.nodes.add(nodes);
+
+ const start = performance.now();
+ wrapper.vm.network.getScale.mockReturnValue(0.1); // Switch to low LOD
+ wrapper.vm.updateLOD();
+ const end = performance.now();
+
+ console.log(`LOD update for ${nodeCount} nodes took ${(end - start).toFixed(2)}ms`);
+ expect(end - start).toBeLessThan(100); // Should be very fast
+ });
+
+ it("performance: icon cache hit vs miss for 500 nodes", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+
+ // Setup 500 nodes with the same icon
+ const iconInfo = { icon_name: "test", foreground_colour: "#000", background_colour: "#fff" };
+ wrapper.vm.pathTable = Array.from({ length: 500 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 }));
+ wrapper.vm.announces = wrapper.vm.pathTable.reduce((acc, cur) => {
+ acc[cur.hash] = {
+ destination_hash: cur.hash,
+ aspect: "lxmf.delivery",
+ display_name: "node",
+ lxmf_user_icon: iconInfo,
+ };
+ return acc;
+ }, {});
+ wrapper.vm.conversations = wrapper.vm.pathTable.reduce((acc, cur) => {
+ acc[cur.hash] = { lxmf_user_icon: iconInfo };
+ return acc;
+ }, {});
+
+ // Mock createIconImage to have some delay
+ wrapper.vm.createIconImage = vi.fn().mockImplementation(async () => {
+ return "blob:mock-icon";
+ });
+
+ const startMiss = performance.now();
+ await wrapper.vm.processVisualization();
+ const endMiss = performance.now();
+ const missTime = endMiss - startMiss;
+
+ // Second run should hit cache
+ const startHit = performance.now();
+ await wrapper.vm.processVisualization();
+ const endHit = performance.now();
+ const hitTime = endHit - startHit;
+
+ console.log(`Icon cache MISS for 500 nodes: ${missTime.toFixed(2)}ms`);
+ console.log(`Icon cache HIT for 500 nodes: ${hitTime.toFixed(2)}ms`);
+
+ // Cache hit should be significantly faster because it avoids 500 async calls (even if resolved instantly)
+ // and doesn't re-create images.
+ expect(hitTime).toBeLessThan(missTime);
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────